﻿import * as childProcess from 'child_process';
import * as q from 'q';
import * as fs from "fs";
import * as iconv from 'iconv-lite';
const UTF8 = 'utf8';
const UTF8_with_bom = 'utf8bom';
const UTF16be = 'utf16be';
const UTF16le = 'utf16le';
const LF = 0x0a;
const CR = 0x0d;
const ZERO_BYTE_DETECTION_BUFFER_MAX_LEN = 512;
const MINIMUM_THRESHOLD = 0.2;
const IGNORE_ENCODINGS = ['ascii', 'utf-8', 'utf-16', 'utf-32'];
const JSCHARDET_TO_ICONV_ENCODINGS: { [name: string]: string } = {
    'ibm866': 'cp866',
    'big5': 'cp950'
};
export const MIME_TEXT = 'text/plain';
export const MIME_BINARY = 'application/octet-stream';
export const MIME_UNKNOWN = 'application/unknown';
interface ISearchWorkerSearchArgs {
    pattern: IPatternInfo;
    fileEncoding: string;
    absolutePaths: string[];
    maxResults?: number;
}
export interface IPatternInfo {
    pattern: string;
    isRegExp?: boolean;
    isWordMatch?: boolean;
    wordSeparators?: string;
    isMultiline?: boolean;
    isCaseSensitive?: boolean;
    isSmartCase?: boolean;
}
interface ReadLinesOptions {
    bufferLength: number;
    encoding: string;
}
export interface ReadResult {
    buffer: NodeBuffer;
    bytesRead: number;
}

export interface IFileMatch {
    resource?: any;
    lineMatches?: ILineMatch[];
}

export interface ILineMatch {
    preview: string;
    lineNumber: number;
    offsetAndLengths: number[][];
}
export interface ISerializedFileMatch {
    path: string;
    lineMatches?: ILineMatch[];
    numMatches?: number;
}

export class FileMatch implements ISerializedFileMatch {
    path: string;
    lineMatches: LineMatch[];

    constructor(path: string) {
        this.path = path;
        this.lineMatches = [];
    }

    addMatch(lineMatch: LineMatch): void {
        this.lineMatches.push(lineMatch);
    }

    serialize(): ISerializedFileMatch {
        let lineMatches: ILineMatch[] = [];
        let numMatches = 0;

        for (let i = 0; i < this.lineMatches.length; i++) {
            numMatches += this.lineMatches[i].offsetAndLengths.length;
            lineMatches.push(this.lineMatches[i].serialize());
        }

        return {
            path: this.path,
            lineMatches,
            numMatches
        };
    }
}

export class LineMatch implements ILineMatch {
    preview: string;
    lineNumber: number;
    offsetAndLengths: number[][];

    constructor(preview: string, lineNumber: number) {
        this.preview = preview.replace(/(\r|\n)*$/, '');
        this.lineNumber = lineNumber;
        this.offsetAndLengths = [];
    }

    addMatch(offset: number, length: number): void {
        this.offsetAndLengths.push([offset, length]);
    }

    serialize(): ILineMatch {
        const result = {
            preview: this.preview,
            lineNumber: this.lineNumber,
            offsetAndLengths: this.offsetAndLengths
        };

        return result;
    }
}
interface RegExpOptions {
    matchCase?: boolean;
    wholeWord?: boolean;
    multiline?: boolean;
    global?: boolean;
}
function escapeRegExpCharacters(value: string): string {
    return value.replace(/[\-\\\{\}\*\+\?\|\^\$\.\[\]\(\)\#]/g, '\\$&');
}
function createRegExp(searchString: string, isRegex: boolean, options: RegExpOptions = {}): RegExp {
    if (!searchString) {
        throw new Error('Cannot create regex from empty string');
    }
    if (!isRegex) {
        searchString = escapeRegExpCharacters(searchString);
    }
    if (options.wholeWord) {
        if (!/\B/.test(searchString.charAt(0))) {
            searchString = '\\b' + searchString;
        }
        if (!/\B/.test(searchString.charAt(searchString.length - 1))) {
            searchString = searchString + '\\b';
        }
    }
    let modifiers = '';
    if (options.global) {
        modifiers += 'g';
    }
    if (!options.matchCase) {
        modifiers += 'i';
    }
    if (options.multiline) {
        modifiers += 'm';
    }

    return new RegExp(searchString, modifiers);
}
export class Search {

    encodingExists(encoding: string): boolean {
        if (encoding == "utf8") {
            return true;
        }
        return iconv.encodingExists(this.toNodeEncoding(encoding));
    }
    isCanceled: boolean = false;

    cancel() {
        this.isCanceled = true;
    }
    searchInFile(absolutePath: string, args: ISearchWorkerSearchArgs, maxResults: number) {
        this.isCanceled = false;
        let fileMatch = null;
        let limitReached = false;
        let numMatches = 0;
        var fileEncoding = this.encodingExists(args.fileEncoding) ? args.fileEncoding : UTF8;
        var contentRegexParttern = createRegExp(args.pattern.pattern, args.pattern.isRegExp,
            { matchCase: args.pattern.isCaseSensitive, wholeWord: args.pattern.isWordMatch, multiline: false, global: true });
        const perLineCallback = (line: string, lineNumber: number) => {
            let lineMatch: LineMatch = null;
            let match = contentRegexParttern.exec(line);

            // Record all matches into file result
            while (match !== null && match[0].length > 0 && !this.isCanceled && !limitReached) {
                if (fileMatch === null) {
                    fileMatch = new FileMatch(absolutePath);
                }

                if (lineMatch === null) {
                    lineMatch = new LineMatch(line, lineNumber);
                    fileMatch.addMatch(lineMatch);
                }

                lineMatch.addMatch(match.index, match[0].length);

                numMatches++;
                if (maxResults && numMatches >= maxResults) {
                    limitReached = true;
                }

                match = contentRegexParttern.exec(line);
            }
        };

        // Read lines buffered to support large files
        return this.readlinesAsync(absolutePath, perLineCallback, { bufferLength: 8096, encoding: fileEncoding }).then(
            () => fileMatch ? { match: fileMatch, limitReached, numMatches } : null);
    }
    decode(buffer: NodeBuffer, encoding: string): string {

        return iconv.decode(buffer, this.toNodeEncoding(encoding));
    }

    toNodeEncoding(enc: string): string {
        if (enc === UTF8_with_bom) {
            return UTF8; // iconv does not distinguish UTF 8 with or without BOM, so we need to help it
        }

        return enc;
    }
    bomLength(encoding: string): number {
        switch (encoding) {
            case UTF8:
                return 3;
            case UTF16be:
            case UTF16le:
                return 2;
        }

        return 0;
    }
    detectEncodingByBOMFromBuffer(buffer: NodeBuffer, bytesRead: number): string {
        if (!buffer || bytesRead < 2) {
            return null;
        }

        const b0 = buffer.readUInt8(0);
        const b1 = buffer.readUInt8(1);

        // UTF-16 BE
        if (b0 === 0xFE && b1 === 0xFF) {
            return UTF16be;
        }

        // UTF-16 LE
        if (b0 === 0xFF && b1 === 0xFE) {
            return UTF16le;
        }

        if (bytesRead < 3) {
            return null;
        }

        const b2 = buffer.readUInt8(2);

        // UTF-8
        if (b0 === 0xEF && b1 === 0xBB && b2 === 0xBF) {
            return UTF8;
        }

        return null;
    }
    async guessEncodingByBuffer(buffer: NodeBuffer): Promise<string> {
        const jschardet = await import('jschardet');

        jschardet.Constants.MINIMUM_THRESHOLD = MINIMUM_THRESHOLD;

        const guessed = jschardet.detect(buffer);
        if (!guessed || !guessed.encoding) {
            return null;
        }

        const enc = guessed.encoding.toLowerCase();

        // Ignore encodings that cannot guess correctly
        // (http://chardet.readthedocs.io/en/latest/supported-encodings.html)
        if (0 <= IGNORE_ENCODINGS.indexOf(enc)) {
            return null;
        }

        return this.toIconvLiteEncoding(guessed.encoding);
    }
    toIconvLiteEncoding(encodingName: string): string {
        const normalizedEncodingName = encodingName.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
        const mapped = JSCHARDET_TO_ICONV_ENCODINGS[normalizedEncodingName];

        return mapped || normalizedEncodingName;
    }

    detectMimeAndEncodingFromBuffer({ buffer, bytesRead }: ReadResult) {
        let enc = this.detectEncodingByBOMFromBuffer(buffer, bytesRead);

        // Detect 0 bytes to see if file is binary (ignore for UTF 16 though)
        let isText = true;
        if (enc !== UTF16be && enc !== UTF16le) {
            for (let i = 0; i < bytesRead && i < ZERO_BYTE_DETECTION_BUFFER_MAX_LEN; i++) {
                if (buffer.readInt8(i) === 0) {
                    isText = false;
                    break;
                }
            }
        }

        return {
            mimes: isText ? [MIME_TEXT] : [MIME_BINARY],
            encoding: enc
        };
    }
    private readlinesAsync(filename: string, perLineCallback: (line: string, lineNumber: number) => void, options: ReadLinesOptions) {
        return q.Promise((resolve, reject) => {
            fs.open(filename, 'r', null, (error: Error, fd: number) => {
                if (error) {
                    return resolve(null);
                }

                const buffer = new Buffer(options.bufferLength);
                let line = '';
                let lineNumber = 0;
                let lastBufferHadTrailingCR = false;

                const readFile = (isFirstRead: boolean, clb: (error: Error) => void): void => {
                    if (this.isCanceled) {
                        return clb(null); // return early if canceled or limit reached
                    }

                    fs.read(fd, buffer, 0, buffer.length, null, (error: Error, bytesRead: number, buffer: NodeBuffer) => {
                        const decodeBuffer = (buffer: NodeBuffer, start: number, end: number): string => {
                            if (options.encoding === UTF8 || options.encoding === UTF8_with_bom) {
                                return buffer.toString(undefined, start, end); // much faster to use built in toString() when encoding is default
                            }

                            return this.decode(buffer.slice(start, end), options.encoding);
                        };

                        const lineFinished = (offset: number): void => {
                            line += decodeBuffer(buffer, pos, i + offset);
                            perLineCallback(line, lineNumber);
                            line = '';
                            lineNumber++;
                            pos = i + offset;
                        };

                        if (error || bytesRead === 0 || this.isCanceled) {
                            return clb(error); // return early if canceled or limit reached or no more bytes to read
                        }

                        let crlfCharSize = 1;
                        let crBytes = [CR];
                        let lfBytes = [LF];
                        let pos = 0;
                        let i = 0;

                        // Detect encoding and mime when this is the beginning of the file
                        if (isFirstRead) {
                            const mimeAndEncoding = this.detectMimeAndEncodingFromBuffer({ buffer, bytesRead });
                            if (mimeAndEncoding.mimes[mimeAndEncoding.mimes.length - 1] !== MIME_TEXT) {
                                return clb(null); // skip files that seem binary
                            }

                            // Check for BOM offset
                            switch (mimeAndEncoding.encoding) {
                                case UTF8:
                                    pos = i = this.bomLength(UTF8);
                                    options.encoding = UTF8;
                                    break;
                                case UTF16be:
                                    pos = i = this.bomLength(UTF16be);
                                    options.encoding = UTF16be;
                                    break;
                                case UTF16le:
                                    pos = i = this.bomLength(UTF16le);
                                    options.encoding = UTF16le;
                                    break;
                            }

                            // when we are running with UTF16le/be, LF and CR are encoded as
                            // two bytes, like 0A 00 (LF) / 0D 00 (CR) for LE or flipped around
                            // for BE. We need to account for this when splitting the buffer into
                            // newlines, and when detecting a CRLF combo.
                            if (options.encoding === UTF16le) {
                                crlfCharSize = 2;
                                crBytes = [CR, 0x00];
                                lfBytes = [LF, 0x00];
                            } else if (options.encoding === UTF16be) {
                                crlfCharSize = 2;
                                crBytes = [0x00, CR];
                                lfBytes = [0x00, LF];
                            }
                        }

                        if (lastBufferHadTrailingCR) {
                            if (buffer[i] === lfBytes[0] && (lfBytes.length === 1 || buffer[i + 1] === lfBytes[1])) {
                                lineFinished(1 * crlfCharSize);
                                i++;
                            } else {
                                lineFinished(0);
                            }

                            lastBufferHadTrailingCR = false;
                        }

                        /**
                         * This loop executes for every byte of every file in the workspace - it is highly performance-sensitive!
                         * Hence the duplication in reading the buffer to avoid a function call. Previously a function call was not
                         * being inlined by V8.
                         */
                        for (; i < bytesRead; ++i) {
                            if (buffer[i] === lfBytes[0] && (lfBytes.length === 1 || buffer[i + 1] === lfBytes[1])) {
                                lineFinished(1 * crlfCharSize);
                            } else if (buffer[i] === crBytes[0] && (crBytes.length === 1 || buffer[i + 1] === crBytes[1])) { // CR (Carriage Return)
                                if (i + crlfCharSize === bytesRead) {
                                    lastBufferHadTrailingCR = true;
                                } else if (buffer[i + crlfCharSize] === lfBytes[0] && (lfBytes.length === 1 || buffer[i + crlfCharSize + 1] === lfBytes[1])) {
                                    lineFinished(2 * crlfCharSize);
                                    i += 2 * crlfCharSize - 1;
                                } else {
                                    lineFinished(1 * crlfCharSize);
                                }
                            }
                        }

                        line += decodeBuffer(buffer, pos, bytesRead);

                        readFile(/*isFirstRead=*/false, clb); // Continue reading
                    });
                };

                readFile(/*isFirstRead=*/true, (error: Error) => {
                    if (error) {
                        return resolve(null);
                    }

                    if (line.length) {
                        perLineCallback(line, lineNumber); // handle last line
                    }

                    fs.close(fd, (error: Error) => {
                        resolve(null);
                    });
                });
            });
        });
    }
}